--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 63d81a02c9a3825f5a53ecbf26da12af4efea462
Parents : 306557c
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-04T12:40:19-06:00
feat(rnpath): implement the blackhole, RNPathHandler and integrate path management APIs
Changes
6 files changed, 322 insertions(+), 9 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 542c25b6..1ba14c20 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -373,6 +373,15 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.rnstatus_handler = value
+ @property
+ def rnpath_handler(self):
+ return self.current_context.rnpath_handler if self.current_context else None
+
+ @rnpath_handler.setter
+ def rnpath_handler(self, value):
+ if self.current_context:
+ self.current_context.rnpath_handler = value
+
@property
def rnprobe_handler(self):
return self.current_context.rnprobe_handler if self.current_context else None
@@ -3976,6 +3985,11 @@ class ReticulumMeshChat:
async def telephone_switch_audio_profile(request):
profile_id = request.match_info.get("profile_id")
try:
+ if self.telephone_manager.telephone is None:
+ return web.json_response(
+ {"message": "Telephone not initialized"}, status=400
+ )
+
await asyncio.to_thread(
self.telephone_manager.telephone.switch_profile,
int(profile_id),
@@ -5522,6 +5536,75 @@ class ReticulumMeshChat:
status=500,
)
+ @routes.get("/api/v1/rnpath/table")
+ async def rnpath_table(request):
+ max_hops = request.query.get("max_hops")
+ if max_hops:
+ max_hops = int(max_hops)
+ try:
+ table = self.rnpath_handler.get_path_table(max_hops=max_hops)
+ return web.json_response({"table": table})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.get("/api/v1/rnpath/rates")
+ async def rnpath_rates(request):
+ try:
+ rates = self.rnpath_handler.get_rate_table()
+ return web.json_response({"rates": rates})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.post("/api/v1/rnpath/drop")
+ async def rnpath_drop(request):
+ data = await request.json()
+ destination_hash = data.get("destination_hash")
+ if not destination_hash:
+ return web.json_response(
+ {"message": "destination_hash is required"}, status=400
+ )
+ try:
+ success = self.rnpath_handler.drop_path(destination_hash)
+ return web.json_response({"success": success})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.post("/api/v1/rnpath/drop-via")
+ async def rnpath_drop_via(request):
+ data = await request.json()
+ transport_instance_hash = data.get("transport_instance_hash")
+ if not transport_instance_hash:
+ return web.json_response(
+ {"message": "transport_instance_hash is required"}, status=400
+ )
+ try:
+ success = self.rnpath_handler.drop_all_via(transport_instance_hash)
+ return web.json_response({"success": success})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.post("/api/v1/rnpath/drop-queues")
+ async def rnpath_drop_queues(request):
+ try:
+ self.rnpath_handler.drop_announce_queues()
+ return web.json_response({"success": True})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.post("/api/v1/rnpath/request")
+ async def rnpath_request(request):
+ data = await request.json()
+ destination_hash = data.get("destination_hash")
+ if not destination_hash:
+ return web.json_response(
+ {"message": "destination_hash is required"}, status=400
+ )
+ try:
+ success = self.rnpath_handler.request_path(destination_hash)
+ return web.json_response({"success": success})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
@routes.post("/api/v1/rnprobe")
async def rnprobe(request):
data = await request.json()
@@ -6461,12 +6544,46 @@ class ReticulumMeshChat:
try:
self.database.misc.add_blocked_destination(destination_hash)
- # drop any existing paths to this destination
- try:
- if hasattr(self, "reticulum") and self.reticulum:
- self.reticulum.drop_path(bytes.fromhex(destination_hash))
- except Exception as e:
- print(f"Failed to drop path for blocked destination: {e}")
+
+ # add to Reticulum blackhole if available and enabled
+ if self.config.blackhole_integration_enabled.get():
+ try:
+ if hasattr(self, "reticulum") and self.reticulum:
+ # Try to resolve identity hash from destination hash
+ identity_hash = None
+ announce = self.database.announces.get_announce_by_hash(
+ destination_hash
+ )
+ if announce and announce.get("identity_hash"):
+ identity_hash = announce["identity_hash"]
+
+ # Use resolved identity hash or fallback to destination hash
+ target_hash = identity_hash or destination_hash
+ dest_bytes = bytes.fromhex(target_hash)
+
+ # Reticulum 1.1.0+
+ if hasattr(self.reticulum, "blackhole_identity"):
+ reason = (
+ f"Blocked in MeshChatX (from {destination_hash})"
+ if identity_hash
+ else "Blocked in MeshChatX"
+ )
+ self.reticulum.blackhole_identity(
+ dest_bytes, reason=reason
+ )
+ else:
+ # fallback to dropping path
+ self.reticulum.drop_path(dest_bytes)
+ except Exception as e:
+ print(f"Failed to blackhole identity in Reticulum: {e}")
+ else:
+ # fallback to just dropping path if integration disabled
+ try:
+ if hasattr(self, "reticulum") and self.reticulum:
+ self.reticulum.drop_path(bytes.fromhex(destination_hash))
+ except Exception as e:
+ print(f"Failed to drop path for blocked destination: {e}")
+
return web.json_response({"message": "ok"})
except Exception:
return web.json_response(
@@ -6486,10 +6603,58 @@ class ReticulumMeshChat:
try:
self.database.misc.delete_blocked_destination(destination_hash)
+
+ # remove from Reticulum blackhole if available and enabled
+ if self.config.blackhole_integration_enabled.get():
+ try:
+ if hasattr(self, "reticulum") and self.reticulum:
+ # Try to resolve identity hash from destination hash
+ identity_hash = None
+ announce = self.database.announces.get_announce_by_hash(
+ destination_hash
+ )
+ if announce and announce.get("identity_hash"):
+ identity_hash = announce["identity_hash"]
+
+ # Use resolved identity hash or fallback to destination hash
+ target_hash = identity_hash or destination_hash
+ dest_bytes = bytes.fromhex(target_hash)
+
+ if hasattr(self.reticulum, "unblackhole_identity"):
+ self.reticulum.unblackhole_identity(dest_bytes)
+ except Exception as e:
+ print(f"Failed to unblackhole identity in Reticulum: {e}")
+
return web.json_response({"message": "ok"})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+ @routes.get("/api/v1/reticulum/blackhole")
+ async def reticulum_blackhole_get(request):
+ if not hasattr(self, "reticulum") or not self.reticulum:
+ return web.json_response(
+ {"error": "Reticulum not initialized"}, status=503
+ )
+
+ try:
+ if hasattr(self.reticulum, "get_blackholed_identities"):
+ identities = self.reticulum.get_blackholed_identities()
+ # Convert bytes keys to hex strings
+ formatted = {}
+ for h, info in identities.items():
+ formatted[h.hex()] = {
+ "source": info.get("source", b"").hex()
+ if info.get("source")
+ else None,
+ "until": info.get("until"),
+ "reason": info.get("reason"),
+ }
+ return web.json_response({"blackholed_identities": formatted})
+ else:
+ return web.json_response({"blackholed_identities": {}})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
# get spam keywords
@routes.get("/api/v1/spam-keywords")
async def spam_keywords_get(request):
@@ -6871,8 +7036,10 @@ class ReticulumMeshChat:
# Add security headers to all responses
response.headers["X-Content-Type-Options"] = "nosniff"
- # Allow framing for docs
- if request.path.startswith("/reticulum-docs/"):
+ # Allow framing for docs and rnode flasher
+ if request.path.startswith("/reticulum-docs/") or request.path.startswith(
+ "/rnode-flasher/"
+ ):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
else:
response.headers["X-Frame-Options"] = "DENY"
@@ -7354,6 +7521,10 @@ class ReticulumMeshChat:
except Exception as e:
print(f"Failed to update GPU disable flag: {e}")
+ if "blackhole_integration_enabled" in data:
+ value = self._parse_bool(data["blackhole_integration_enabled"])
+ self.config.blackhole_integration_enabled.set(value)
+
# update voicemail settings
if "voicemail_enabled" in data:
self.config.voicemail_enabled.set(
@@ -8234,6 +8405,7 @@ class ReticulumMeshChat:
"libretranslate_url": ctx.config.libretranslate_url.get(),
"desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(),
"desktop_hardware_acceleration_enabled": ctx.config.desktop_hardware_acceleration_enabled.get(),
+ "blackhole_integration_enabled": ctx.config.blackhole_integration_enabled.get(),
}
# try and get a name for the provided identity hash
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 660336cf..7163af35 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -236,6 +236,13 @@ class ConfigManager:
)
self.message_font_size = self.IntConfig(self, "message_font_size", 14)
+ # blackhole integration config
+ self.blackhole_integration_enabled = self.BoolConfig(
+ self,
+ "blackhole_integration_enabled",
+ True,
+ )
+
def get(self, key: str, default_value=None) -> str | None:
return self.db.config.get(key, default_value)
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 3deb7e6a..2d3c2d5c 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -17,6 +17,7 @@ from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.rncp_handler import RNCPHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
+from meshchatx.src.backend.rnpath_handler import RNPathHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.translator_handler import TranslatorHandler
from meshchatx.src.backend.forwarding_manager import ForwardingManager
@@ -195,6 +196,9 @@ class IdentityContext:
self.rnstatus_handler = RNStatusHandler(
reticulum_instance=getattr(self.app, "reticulum", None),
)
+ self.rnpath_handler = RNPathHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ )
self.rnprobe_handler = RNProbeHandler(
reticulum_instance=getattr(self.app, "reticulum", None),
identity=self.identity,
diff --git a/meshchatx/src/backend/persistent_log_handler.py b/meshchatx/src/backend/persistent_log_handler.py
index 76a0e55a..61d38427 100644
--- a/meshchatx/src/backend/persistent_log_handler.py
+++ b/meshchatx/src/backend/persistent_log_handler.py
@@ -1,5 +1,6 @@
import collections
import logging
+import re
import threading
import time
from datetime import UTC, datetime
@@ -22,6 +23,10 @@ class PersistentLogHandler(logging.Handler):
self.message_counts = collections.defaultdict(int)
self.last_reset_time = time.time()
+ # UA and IP tracking
+ self.known_ips = set()
+ self.known_uas = set()
+
def set_database(self, database):
with self.lock:
self.database = database
@@ -54,8 +59,54 @@ class PersistentLogHandler(logging.Handler):
except Exception:
self.handleError(record)
+ def _detect_access_anomaly(self, message):
+ """Detect anomalies in aiohttp access logs."""
+ # Regex to extract IP and User-Agent from aiohttp access log
+ # Format: IP [date] "GET ..." status size "referer" "User-Agent"
+ match = re.search(
+ r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
+ )
+ if match:
+ ip = match.group(1)
+ ua = match.group(2)
+
+ with self.lock:
+ is_anomaly = False
+ anomaly_type = None
+
+ # Detect if this is a different UA or IP from what we've seen recently
+ if len(self.known_ips) > 0 and ip not in self.known_ips:
+ is_anomaly = True
+ anomaly_type = "multi_ip"
+
+ if len(self.known_uas) > 0 and ua not in self.known_uas:
+ is_anomaly = True
+ if anomaly_type:
+ anomaly_type = "multi_ip_ua"
+ else:
+ anomaly_type = "multi_ua"
+
+ self.known_ips.add(ip)
+ self.known_uas.add(ua)
+
+ # Cap the tracking sets to prevent memory growth
+ if len(self.known_ips) > 100:
+ self.known_ips.clear()
+ if len(self.known_uas) > 100:
+ self.known_uas.clear()
+
+ return is_anomaly, anomaly_type
+
+ return False, None
+
def _detect_anomaly(self, record, message, timestamp):
- # Only detect anomalies for WARNING level and above
+ # 1. Access anomaly detection (UA/IP) - checked for all levels of aiohttp.access
+ if record.name == "aiohttp.access":
+ is_acc_anomaly, acc_type = self._detect_access_anomaly(message)
+ if is_acc_anomaly:
+ return True, acc_type
+
+ # Only detect other anomalies for WARNING level and above
if record.levelno < logging.WARNING:
return False, None
diff --git a/meshchatx/src/backend/rnpath_handler.py b/meshchatx/src/backend/rnpath_handler.py
new file mode 100644
index 00000000..92b54d52
--- /dev/null
+++ b/meshchatx/src/backend/rnpath_handler.py
@@ -0,0 +1,62 @@
+import RNS
+
+
+class RNPathHandler:
+ def __init__(self, reticulum_instance: RNS.Reticulum):
+ self.reticulum = reticulum_instance
+
+ def get_path_table(self, max_hops: int = None):
+ table = self.reticulum.get_path_table(max_hops=max_hops)
+ formatted_table = []
+ for entry in table:
+ formatted_table.append(
+ {
+ "hash": entry["hash"].hex(),
+ "hops": entry["hops"],
+ "via": entry["via"].hex(),
+ "interface": entry["interface"],
+ "expires": entry["expires"],
+ }
+ )
+ return sorted(formatted_table, key=lambda e: (e["interface"], e["hops"]))
+
+ def get_rate_table(self):
+ table = self.reticulum.get_rate_table()
+ formatted_table = []
+ for entry in table:
+ formatted_table.append(
+ {
+ "hash": entry["hash"].hex(),
+ "last": entry["last"],
+ "timestamps": entry["timestamps"],
+ "rate_violations": entry["rate_violations"],
+ "blocked_until": entry["blocked_until"],
+ }
+ )
+ return sorted(formatted_table, key=lambda e: e["last"])
+
+ def drop_path(self, destination_hash: str) -> bool:
+ try:
+ dest_bytes = bytes.fromhex(destination_hash)
+ return self.reticulum.drop_path(dest_bytes)
+ except Exception:
+ return False
+
+ def drop_all_via(self, transport_instance_hash: str) -> bool:
+ try:
+ ti_bytes = bytes.fromhex(transport_instance_hash)
+ return self.reticulum.drop_all_via(ti_bytes)
+ except Exception:
+ return False
+
+ def drop_announce_queues(self):
+ self.reticulum.drop_announce_queues()
+ return True
+
+ def request_path(self, destination_hash: str):
+ try:
+ dest_bytes = bytes.fromhex(destination_hash)
+ RNS.Transport.request_path(dest_bytes)
+ return True
+ except Exception:
+ return False
diff --git a/meshchatx/src/backend/rnstatus_handler.py b/meshchatx/src/backend/rnstatus_handler.py
index 58c65b3e..50aa86cb 100644
--- a/meshchatx/src/backend/rnstatus_handler.py
+++ b/meshchatx/src/backend/rnstatus_handler.py
@@ -1,5 +1,6 @@
import time
from typing import Any
+import RNS
def size_str(num, suffix="B"):
@@ -53,6 +54,19 @@ class RNStatusHandler:
"link_count": link_count,
}
+ blackhole_enabled = False
+ blackhole_sources = []
+ blackhole_count = 0
+ try:
+ blackhole_enabled = RNS.Reticulum.publish_blackhole_enabled()
+ blackhole_sources = [s.hex() for s in RNS.Reticulum.blackhole_sources()]
+
+ # Get count of blackholed identities
+ if self.reticulum and hasattr(self.reticulum, "get_blackholed_identities"):
+ blackhole_count = len(self.reticulum.get_blackholed_identities())
+ except Exception:
+ pass
+
interfaces = stats.get("interfaces", [])
if sorting and isinstance(sorting, str):
@@ -211,4 +225,7 @@ class RNStatusHandler:
"interfaces": formatted_interfaces,
"link_count": link_count,
"timestamp": time.time(),
+ "blackhole_enabled": blackhole_enabled,
+ "blackhole_sources": blackhole_sources,
+ "blackhole_count": blackhole_count,
}
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────